Skip to content

fix(FLOW-10): Enforce fair FIFO queue ordering on-chain#46

Merged
liobrasil merged 4 commits intomainfrom
mpeter/FLOW-10-enforce-fifo-queue-ordering
Apr 9, 2026
Merged

fix(FLOW-10): Enforce fair FIFO queue ordering on-chain#46
liobrasil merged 4 commits intomainfrom
mpeter/FLOW-10-enforce-fifo-queue-ordering

Conversation

@m-Peter
Copy link
Copy Markdown
Collaborator

@m-Peter m-Peter commented Feb 3, 2026

Closes #25

FLOW-10: FIFO Queue Is Not Enforced on-Chain yet Costs O(n) to Maintain

Introduce an optimized queue data structure (mapping-based with head & tail pointers), to avoid the high gas costs of maintaining the FIFO order on-chain. Both enqueue & dequeue operations are now O(1), the cancellation part remain O(n), while the batch drop is now O(m*n) for dropping a batch of m requests.

The previous queue data structure required an array and a mapping, and had O(n) performance when removing a request from the pending queue:

/// @notice Array of pending request IDs awaiting processing (FIFO order)
uint256[] public pendingRequestIds;

/// @notice Index of request ID in global pending array (for O(1) lookup)
mapping(uint256 => uint256) private _requestIndexInGlobalArray;

In additionn _startProcessingInternal now has a check which verifies that the request being processed is the head of the requestsQueue FIFO queue:

_removeUserPendingRequest(requestId);
uint256 reqId = _dequeueRequest();
if (reqId != requestId) revert RequestProcessOutOfOrder(reqId, requestId);

If that is not the case, the function call reverts with RequestProcessOutOfOrder.

The Cadence Scheduler that schedules & processes the requests, is fetching the request IDs with:

worker.getPendingRequestsFromEVM(startIndex: 0, count: fetchCount)

which returns a given count of pending EVM requests from the queue, in FIFO order.
These are fed to preprocessRequests(), and after the validation checks, they are classified as successful/rejected, and they are then passed in to startProcessingBatch(), which drops the rejected request IDs, and calls _startProcessingInternal() for each individual successful request ID.

Migration Notes:
With the public pendingRequestIds variable, the getter pendingRequestIds(uint256 index) → uint256 is no longer available. Callers should use getPendingRequestIds(), as is the case with the Cadence side in this repository.

@m-Peter m-Peter self-assigned this Feb 3, 2026
@m-Peter m-Peter added Low Low severity security finding ⎈ QuantStamp QuantStamp audit finding Performance Improvements around performance labels Feb 3, 2026
@m-Peter m-Peter force-pushed the mpeter/FLOW-10-enforce-fifo-queue-ordering branch 3 times, most recently from ebe63d0 to 8ad764e Compare February 3, 2026 16:24
@m-Peter m-Peter force-pushed the mpeter/FLOW-10-enforce-fifo-queue-ordering branch 7 times, most recently from 9a20c8f to 5717ad2 Compare February 9, 2026 14:41
@onflow onflow deleted a comment from claude bot Feb 10, 2026
@m-Peter m-Peter force-pushed the mpeter/FLOW-10-enforce-fifo-queue-ordering branch from 74c948d to 371f132 Compare February 10, 2026 10:44
@claude
Copy link
Copy Markdown

claude bot commented Feb 10, 2026

Review: fix(FLOW-10) — Enforce fair FIFO queue ordering on-chain

The mapping-based queue (head/tail pointers) is a sound replacement for the O(n)-shift array. The validation loop in startProcessingBatch correctly enforces that both input arrays together form a contiguous, in-order prefix of the queue, and the dequeue check in _startProcessingInternal provides a correct belt-and-suspenders guard after rejected requests are dropped.

Three concrete issues found:


1. Dead InvalidRequestState check in startProcessingBatch — medium

File: solidity/src/FlowYieldVaultsRequests.sol, lines 1029–1032

Request storage request = requests[reqId];
if (request.status != RequestStatus.PENDING)
    revert InvalidRequestState();          // ← unreachable
revert RequestProcessOutOfOrder(reqId, candidateReqId);

reqId is a non-zero slot read from _requestsQueue[head+i]. The queue invariant guarantees every slot in [head, tail) holds a PENDING request — requests are removed from the queue the instant their status changes. So request.status is always PENDING here; InvalidRequestState never fires, and the only reachable revert is RequestProcessOutOfOrder. The dead check should be removed: it misleads reviewers into believing a non-PENDING queue entry is possible and could mask the real error with a confusing one.


2. Wrong error selector in _dropQueuedRequest — low

File: solidity/src/FlowYieldVaultsRequests.sol, line 2107

if (!requestFound) revert RequestNotFound();   // should be RequestNotInQueue(requestId)

RequestNotFound() is used everywhere else to mean "no entry in the requests mapping". Here the request definitely exists (callers confirm status == PENDING before calling). The correct selector is RequestNotInQueue(requestId), which is precisely what was introduced for this scenario and whose requestId argument aids debugging.


3. Missing test: re-processing an already-PROCESSING request — medium

The old test_StartProcessingBatch_RevertNotPending verified that submitting a request already in PROCESSING status is rejected. It was deleted and not replaced. The new FIFO enforcement still prevents it (a PROCESSING request has been dequeued, so RequestNotInQueue or RequestProcessOutOfOrder fires), but that invariant is no longer documented in the test suite. See inline comment for a suggested replacement test.


No issues found with: queue arithmetic correctness, FIFO ordering semantics, refund/accounting invariants, sentinel-value handling, pagination bounds, Cadence–Solidity request-type sync, or the receive() removal (that entry was already absent from the Solidity source; the ABI file was just corrected).

@m-Peter m-Peter force-pushed the mpeter/FLOW-10-enforce-fifo-queue-ordering branch from 71b9aeb to 9d7d107 Compare February 11, 2026 15:18
@m-Peter m-Peter changed the title Enforce fair FIFO queue ordering on-chain fix(FLOW-10): Enforce fair FIFO queue ordering on-chain Mar 10, 2026
@m-Peter m-Peter force-pushed the mpeter/FLOW-10-enforce-fifo-queue-ordering branch 4 times, most recently from 1760973 to 95d1302 Compare March 11, 2026 11:45
@m-Peter m-Peter marked this pull request as ready for review March 11, 2026 11:54
@m-Peter m-Peter requested review from liobrasil and nvdtf March 11, 2026 14:26
@m-Peter m-Peter force-pushed the mpeter/FLOW-10-enforce-fifo-queue-ordering branch from 95d1302 to 0da95a3 Compare March 12, 2026 16:50
@m-Peter m-Peter force-pushed the mpeter/FLOW-10-enforce-fifo-queue-ordering branch from 0da95a3 to 76c7bec Compare March 23, 2026 16:44
@m-Peter m-Peter force-pushed the mpeter/FLOW-10-enforce-fifo-queue-ordering branch from 5267d82 to a1691be Compare March 31, 2026 16:51
@m-Peter m-Peter force-pushed the mpeter/FLOW-10-enforce-fifo-queue-ordering branch from a1691be to 2749c7f Compare March 31, 2026 17:35
@m-Peter m-Peter force-pushed the mpeter/FLOW-10-enforce-fifo-queue-ordering branch from 2749c7f to a8d403a Compare March 31, 2026 17:53
@m-Peter m-Peter force-pushed the mpeter/FLOW-10-enforce-fifo-queue-ordering branch from a8d403a to 7001cdf Compare March 31, 2026 18:22
*
* @param requestId The request ID to remove from the pending requests queue.
*/
function _dropQueuedRequest(uint256 requestId) internal {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

O(n) regression for cancelRequest

_dropQueuedRequest does a linear scan-and-shift across the entire queue, replacing the former O(1) swap-and-pop (which used _requestIndexInGlobalArray). This is the correct trade-off to maintain FIFO order, but it means that every user-initiated cancelRequest now costs gas proportional to queue depth, not O(1).

With maxPendingRequestsPerUser = 10 and N active users the queue is bounded at 10 × N entries, so gas stays manageable at current scale. Worth noting explicitly in the migration notes so operators are aware that raising maxPendingRequestsPerUser amplifies cancellation cost.

Copy link
Copy Markdown
Collaborator Author

@m-Peter m-Peter Mar 31, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

cancelRequest previously used _removePendingRequest(requestId);, which stated:

// === GLOBAL PENDING ARRAY REMOVAL ===
// Uses O(1) lookup + O(n) shift to maintain FIFO order
// FIFO order is critical for DeFi fairness - requests must be processed in submission order

So this was also O(n).

// === VALIDATION ===
Request storage request = requests[reqId];
if (request.status != RequestStatus.PENDING)
revert InvalidRequestState();
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This InvalidRequestState check is dead code. The _requestsQueue mapping only ever holds IDs for requests that are currently PENDING — every transition out of PENDING (_dequeueRequest, _dropQueuedRequest) removes the ID from the queue first. So reqId read from the queue is always PENDING here.

Leaving dead code in a hot code path is misleading: it suggests to future readers that non-PENDING entries can appear in the queue, which they cannot. Consider removing it (or replacing with a assert-style comment if you want to document the invariant).

requestFound = true;
}

// Shift the matching request to the queue's tail, then delete it
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Inaccurate comment. The algorithm does not "shift the matching request to the tail." It shifts all elements after the match one slot to the left (closing the gap), and then deletes the now-duplicated last slot. A clearer description:

// Once found, shift each subsequent element one position left,
// then delete the last (now-duplicate) slot at tail-1.

uint256 requestId;
// If reqId is 0, it means we went over the boundaries of
// _requestsQueue.
uint256 reqId = _requestsQueue[_requestsQueueHead+i];
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The reqId == 0 guard fires only inside the if (j < successfulRequestIds.length) and if (k < rejectedRequestIds.length) branches. When both arrays are exhausted midway through the loop (impossible in practice because totalRequests = j_max + k_max keeps j + k == i+1 at every iteration), a zero value at a queue slot would silently skip past the guard and reach the RequestProcessOutOfOrder revert instead.

This is unreachable today, but worth noting that the "went over the boundaries" invariant is only protected within each branch. The loop invariant j + k == i+1 (one match per iteration) prevents this from being exploitable, but it is worth a clarifying comment.

vm.stopPrank();

vm.startPrank(coa);
// First 3 successful, transition to PROCESSING
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good test. There is a symmetric gap worth covering: a request that the COA lists in successfulRequestIds is user-cancelled before startProcessingBatch executes. The expected behaviour is identical — the queue has shifted, so the validation loop fires RequestProcessOutOfOrder at the position where the cancelled request used to sit — but an explicit test would document that invariant.

Copy link
Copy Markdown
Contributor

@liobrasil liobrasil left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

@liobrasil liobrasil merged commit b30f034 into main Apr 9, 2026
5 checks passed
@liobrasil liobrasil deleted the mpeter/FLOW-10-enforce-fifo-queue-ordering branch April 9, 2026 18:03
Comment on lines +1029 to +1032
// === VALIDATION ===
Request storage request = requests[reqId];
if (request.status != RequestStatus.PENDING)
revert InvalidRequestState();
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Dead code — InvalidRequestState can never fire here.

reqId is read from _requestsQueue[_requestsQueueHead+i], which is non-zero (checked by the reqId == 0 guards above). The queue invariant guarantees every slot in [_requestsQueueHead, _requestsQueueTail) holds a PENDING request — only PENDING requests are enqueued, and they are removed (via _dequeueRequest or _dropQueuedRequest) the moment their status changes. So request.status is always PENDING here, and revert InvalidRequestState() is unreachable.

The check should be removed. Leaving dead revert code in a validation path is misleading: a reader (or auditor) will assume it guards a real code path and waste time reasoning about it, and any future queue-entry that somehow carries a non-PENDING status would silently mask the real RequestProcessOutOfOrder error with a confusing one.

Suggested change
// === VALIDATION ===
Request storage request = requests[reqId];
if (request.status != RequestStatus.PENDING)
revert InvalidRequestState();
// requestId currently holds the last-assigned candidate

}

// Decrement the queue tail only if the given requestId was found
if (!requestFound) revert RequestNotFound();
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wrong error selector — should be RequestNotInQueue(requestId), not RequestNotFound().

RequestNotFound() means "no entry in the requests mapping" (see its uses in cancelRequest, completeProcessing, _startProcessingInternal). But here the request definitely exists — callers of _dropQueuedRequest only reach this point after confirming request.status == PENDING, which requires the request to have been initialised. The missing invariant is that the PENDING request isn't found in the queue (queue corruption).

Using RequestNotFound() also conflicts with how the error is used elsewhere and makes RequestNotInQueue a partially-dead code path for corruption scenarios.

Suggested change
if (!requestFound) revert RequestNotFound();
if (!requestFound) revert RequestNotInQueue(requestId);

Comment on lines +368 to 393
function test_StartProcessingBatch_RevertRequestNotInQueueWhenQueueEmpty() public {
vm.startPrank(coa);
vm.expectRevert(abi.encodeWithSelector(
FlowYieldVaultsRequests.RequestNotInQueue.selector,
1
));
_startProcessingBatch(1);
vm.stopPrank();
}

function test_StartProcessingBatch_RevertRequestNotInQueueWhenQueueNonEmpty() public {
vm.prank(user);
uint256 reqId = c.createYieldVault{value: 1 ether}(NATIVE_FLOW, 1 ether, VAULT_ID, STRATEGY_ID);

vm.startPrank(coa);
_startProcessingBatch(reqId);
uint256[] memory successfulRequestIds = new uint256[](5);
successfulRequestIds[0] = reqId;
successfulRequestIds[1] = reqId+2;

vm.expectRevert(FlowYieldVaultsRequests.InvalidRequestState.selector);
_startProcessingBatch(reqId);
vm.expectRevert(abi.encodeWithSelector(
FlowYieldVaultsRequests.RequestNotInQueue.selector,
reqId+2
));
c.startProcessingBatch(successfulRequestIds, new uint256[](0));
vm.stopPrank();
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing coverage: re-processing an already-PROCESSING request.

The old test_StartProcessingBatch_RevertNotPending verified that calling startProcessingBatch on a request already in PROCESSING status is rejected. That test was deleted and none of the new tests replace it.

The new enforcement still prevents this (a PROCESSING request has been dequeued, so the queue no longer contains it, and the COA would get RequestNotInQueue or RequestProcessOutOfOrder), but the expectation is no longer documented as a test. Consider adding:

function test_StartProcessingBatch_RevertWhenRequestAlreadyProcessing() public {
    vm.prank(user);
    uint256 reqId = c.createYieldVault{value: 1 ether}(NATIVE_FLOW, 1 ether, VAULT_ID, STRATEGY_ID);

    vm.startPrank(coa);
    _startProcessingBatch(reqId); // PENDING → PROCESSING, also dequeued

    // Queue is now empty; re-submitting the same ID must revert
    vm.expectRevert(abi.encodeWithSelector(
        FlowYieldVaultsRequests.RequestNotInQueue.selector,
        reqId
    ));
    _startProcessingBatch(reqId);
    vm.stopPrank();
}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Low Low severity security finding Performance Improvements around performance ⎈ QuantStamp QuantStamp audit finding

Projects

None yet

Development

Successfully merging this pull request may close these issues.

FLOW-10: FIFO Queue Is Not Enforced on-Chain yet Costs O(n) to Maintain

2 participants